Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | 4x 4x 4x 4x 4x 3x 3x 4x 3x 3x 3x 3x 3x 6x 3x | import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getDb } from "@/lib/db";
import { requireCollectionAccess } from "@/lib/collection-access";
import { ItemController } from "@/server/api/items";
import { PhotoController } from "@/server/api/photos";
import { DecisionController } from "@/server/api/decisions";
import { SessionController } from "@/server/api/sessions";
import { AppLayout } from "@/components/layout";
import { trail } from "@/components/layout/breadcrumb-trails";
import { Card } from "@/components/ui";
import { ItemPhotoCarousel } from "@/features/items/ItemPhotoCarousel";
import { ItemPanelForm } from "@/features/items/ItemPanelForm";
import { InYourLifeChip } from "@/features/items/InYourLifeChip";
import { ItemOverflowMenu } from "@/features/items/ItemOverflowMenu";
import { deriveUsage } from "@/lib/derive-usage";
import { itemDisplayName, itemSubtitle } from "@/lib/items";
interface ItemDetailPageProps {
params: Promise<{ id: string; itemNumber: string }>;
}
export default async function ItemDetailPage({ params }: ItemDetailPageProps) {
const { id, itemNumber } = await params;
const session = await getSession();
Iif (!session?.user) {
redirect("/login");
}
const db = getDb();
// Admits only owner or member — and every member may write (items:write / photos:write).
const collection = await requireCollectionAccess(db, id, session.user.id);
const number = Number(itemNumber);
const item =
number > 0 && Number.isInteger(number)
? await ItemController.fromDb(db).getByCollectionAndNumber(collection.id, number)
: null;
Iif (!item) {
redirect(`/collections/${collection.slug ?? collection.id}`);
}
const [photos, curatorEvents, curatorSessions] = await Promise.all([
PhotoController.fromDb(db).findByItem(item.id),
// Server-only: only the folded answer for this one item reaches the client.
DecisionController.fromDb(db).listForCurator(collection.id, session.user.id),
SessionController.fromDb(db).listByCollectionAndCurator(collection.id, session.user.id),
]);
const usage = deriveUsage(curatorEvents, new Set([item.id])).get(item.id);
const openSession = curatorSessions.find((s) => s.status === "active" || s.status === "paused");
// findByItem has no ORDER BY: cover first, then upload order (spec §6).
const orderedPhotos = [...photos]
.sort(
(a, b) =>
Number(b.isHero) - Number(a.isHero) ||
+new Date(a.createdAt) - +new Date(b.createdAt) ||
(a.id < b.id ? -1 : 1),
)
.map((p) => ({
id: p.id,
itemId: item.id,
collectionId: collection.id,
rotation: p.rotation,
updatedAt: p.updatedAt,
alt: itemDisplayName(item),
isHero: p.isHero,
}));
return (
<AppLayout breadcrumbs={trail.item(collection, item)}>
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-x-3">
<div className="min-w-0 flex-1">
<h1 className="text-3xl text-ink sm:text-4xl">{itemDisplayName(item)}</h1>
<p className="mt-1 text-sm text-ink-soft">{itemSubtitle(item, new Date())}</p>
</div>
<ItemOverflowMenu
itemId={item.id}
listHref={`/collections/${collection.slug ?? collection.id}`}
isOwner={collection.ownerId === session.user.id}
/>
</header>
<div className="grid gap-6 md:grid-cols-[3fr_2fr] md:items-start">
<ItemPhotoCarousel
photos={orderedPhotos}
collectionId={collection.id}
itemId={item.id}
itemName={itemDisplayName(item)}
/>
<Card variant="card" padding="lg" className="space-y-6">
<InYourLifeChip
collectionId={collection.id}
itemId={item.id}
sessionId={openSession?.id ?? null}
initial={usage}
/>
<ItemPanelForm item={item} collectionId={collection.id} />
</Card>
</div>
</div>
</AppLayout>
);
}
|